page.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. import './style.scss';
  2. import '@/app/styles/stock-common.scss';
  3. import { cache } from 'react';
  4. import { Metadata } from 'next';
  5. import Link from 'next/link';
  6. import { notFound } from 'next/navigation';
  7. import { fetchStockDetail, fetchStockHistory } from '@/lib/api/stock';
  8. import { fetchStockDisclosures } from '@/lib/api/market';
  9. import { fetchStockDividends, fetchStockLending, fetchStockCbExercises } from '@/lib/api/seibro';
  10. import { fetchStockBoardPosts } from '@/lib/api/community';
  11. import { changeDirection, changeSymbol, formatChangeRate, formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
  12. import Pager from '../_components/Pager';
  13. import DisclosureList from '../../market/_components/DisclosureList';
  14. import StockDiscussion from './_components/StockDiscussion';
  15. import StockDividends from './_components/StockDividends';
  16. import StockLending from './_components/StockLending';
  17. import StockCbExercises from './_components/StockCbExercises';
  18. const TABS = [
  19. { value: 'price', label: '시세' },
  20. { value: 'dividends', label: '배당' },
  21. { value: 'lending', label: '대차' },
  22. { value: 'cb', label: '전환권' },
  23. { value: 'news', label: '뉴스' },
  24. { value: 'disclosure', label: '공시' },
  25. { value: 'discussion', label: '토론' }
  26. ];
  27. const HISTORY_PER_PAGE = 30;
  28. const DISCUSSION_PER_PAGE = 20;
  29. const DIVIDENDS_PER_PAGE = 30;
  30. const LENDING_PER_PAGE = 90;
  31. const CB_PER_PAGE = 30;
  32. type Props = {
  33. params: Promise<{
  34. code: string;
  35. }>;
  36. searchParams: Promise<{
  37. tab?: string;
  38. page?: string;
  39. }>;
  40. };
  41. // generateMetadata + 본문 이중 호출 방지 (요청 단위 dedupe)
  42. const getDetail = cache(async (code: string) => {
  43. return await fetchStockDetail(code);
  44. });
  45. export async function generateMetadata({ params }: Props): Promise<Metadata>
  46. {
  47. const { code } = await params;
  48. const res = await getDetail(code);
  49. if (!res.success || !res.data) {
  50. return {
  51. title: '종목 상세 — 개미투자'
  52. };
  53. }
  54. return {
  55. title: `${res.data.name}(${res.data.code}) 주가·시세 — 개미투자`,
  56. description: `${res.data.name}(${res.data.code}) 전일 종가·등락률·거래량·일별 시세 — 전일 종가 기준(T+1)`
  57. };
  58. }
  59. export default async function StockDetailPage({ params, searchParams }: Props)
  60. {
  61. const { code } = await params;
  62. const query = await searchParams;
  63. const tab = TABS.some(c => c.value === query.tab) ? (query.tab as string) : 'price';
  64. const page = Math.max(Number(query.page) || 1, 1);
  65. const res = await getDetail(code);
  66. // 없는 코드(형식 불일치 포함)는 404
  67. if (!res.success && res.status === 404) {
  68. return notFound();
  69. }
  70. // 백엔드 미기동 등 그 외 실패 — 에러 UI 로 우아하게 처리
  71. if (!res.success || !res.data) {
  72. return (
  73. <div id='stock-detail'>
  74. <div className='stock-detail__error' role='alert'>
  75. <strong>종목 정보를 불러오지 못했습니다.</strong>
  76. 잠시 후 다시 시도해 주세요.
  77. </div>
  78. </div>
  79. );
  80. }
  81. const detail = res.data;
  82. const latest = detail.recentPrices.length > 0 ? detail.recentPrices[0] : null;
  83. const direction = changeDirection(detail.changeRate);
  84. const statusLabel = tradingStatusLabel(detail.tradingStatus);
  85. // 시세 탭만 히스토리 조회 (request-time)
  86. const history = tab === 'price' ? await fetchStockHistory(code, { page, perPage: HISTORY_PER_PAGE }) : null;
  87. // 공시 탭 — 종목별 DART 공시 조회 (request-time)
  88. const disclosures = tab === 'disclosure' ? await fetchStockDisclosures(code, { page }) : null;
  89. // 토론 탭 — 종목 가상보드 글 목록 (request-time)
  90. const discussion = tab === 'discussion' ? await fetchStockBoardPosts(code, { page, perPage: DISCUSSION_PER_PAGE }) : null;
  91. // SEIBro 종목 탭 — 배당 이력 / 대차 시계열 / CB·BW 행사 (request-time)
  92. const dividends = tab === 'dividends' ? await fetchStockDividends(code, { page, perPage: DIVIDENDS_PER_PAGE }) : null;
  93. const lending = tab === 'lending' ? await fetchStockLending(code, { page, perPage: LENDING_PER_PAGE }) : null;
  94. const cbExercises = tab === 'cb' ? await fetchStockCbExercises(code, { page, perPage: CB_PER_PAGE }) : null;
  95. return (
  96. <div id='stock-detail'>
  97. {/* 상단 요약 — T+1 데이터임을 명시 */}
  98. <header className='stock-detail__summary'>
  99. <div className='stock-detail__title'>
  100. <h1>{detail.name}</h1>
  101. <span className='stock-detail__code'>{detail.code}</span>
  102. <span className={`stock-badge stock-badge--${detail.market.toLowerCase()}`}>{detail.market}</span>
  103. {statusLabel && (
  104. <span className={`stock-badge stock-badge--${detail.tradingStatus.toLowerCase()}`}>{statusLabel}</span>
  105. )}
  106. {detail.sectorName && <span className='stock-detail__sector'>{detail.sectorName}</span>}
  107. </div>
  108. <div className='stock-detail__price'>
  109. <strong className={`stock-detail__close stock-change stock-change--${direction}`}>
  110. {formatNumber(detail.closePrice)}
  111. </strong>
  112. <span className={`stock-detail__diff stock-change stock-change--${direction}`}>
  113. {changeSymbol(direction)} {latest ? formatNumber(Math.abs(latest.changeAmount)) : '—'} ({formatChangeRate(detail.changeRate)})
  114. </span>
  115. </div>
  116. <dl className='stock-detail__meta'>
  117. <div>
  118. <dt>거래량</dt>
  119. <dd>{formatNumber(latest?.volume)}</dd>
  120. </div>
  121. <div>
  122. <dt>거래대금</dt>
  123. <dd>{formatKrwCompact(latest?.tradingValue)}</dd>
  124. </div>
  125. <div>
  126. <dt>시가총액</dt>
  127. <dd>{formatKrwCompact(detail.marketCap)}</dd>
  128. </div>
  129. </dl>
  130. <p className='stock-detail__basis'>
  131. 전일 기준 · {detail.lastTradingDate ?? '데이터 준비 중'} 종가 (T+1 반영)
  132. </p>
  133. </header>
  134. {/* 탭 */}
  135. <nav className='stock-detail__tabs' aria-label='종목 상세 탭'>
  136. {TABS.map(item => (
  137. <Link
  138. key={item.value}
  139. href={item.value === 'price' ? `/stock/${detail.code}` : `/stock/${detail.code}?tab=${item.value}`}
  140. {...(tab === item.value ? { 'aria-current': 'page' as const } : {})}
  141. >
  142. {item.label}
  143. </Link>
  144. ))}
  145. </nav>
  146. <section className='stock-detail__panel'>
  147. {tab === 'disclosure' ? (
  148. !disclosures || !disclosures.success || !disclosures.data ? (
  149. <div className='stock-detail__error' role='alert'>
  150. <strong>공시를 불러오지 못했습니다.</strong>
  151. 잠시 후 다시 시도해 주세요.
  152. </div>
  153. ) : (
  154. <>
  155. <DisclosureList rows={disclosures.data.list} />
  156. <Pager
  157. total={disclosures.data.total}
  158. page={page}
  159. perPage={20}
  160. basePath={`/stock/${detail.code}`}
  161. query={{ tab: 'disclosure' }}
  162. />
  163. </>
  164. )
  165. ) : tab === 'discussion' ? (
  166. !discussion || !discussion.success || !discussion.data ? (
  167. <div className='stock-detail__error' role='alert'>
  168. <strong>토론을 불러오지 못했습니다.</strong>
  169. 잠시 후 다시 시도해 주세요.
  170. </div>
  171. ) : (
  172. <StockDiscussion
  173. code={detail.code}
  174. stockName={detail.name}
  175. page={page}
  176. perPage={DISCUSSION_PER_PAGE}
  177. data={discussion.data}
  178. />
  179. )
  180. ) : tab === 'dividends' ? (
  181. !dividends || !dividends.success || !dividends.data ? (
  182. <div className='stock-detail__error' role='alert'>
  183. <strong>배당 이력을 불러오지 못했습니다.</strong>
  184. 잠시 후 다시 시도해 주세요.
  185. </div>
  186. ) : (
  187. <>
  188. <StockDividends rows={dividends.data.list} />
  189. <Pager
  190. total={dividends.data.total}
  191. page={page}
  192. perPage={DIVIDENDS_PER_PAGE}
  193. basePath={`/stock/${detail.code}`}
  194. query={{ tab: 'dividends' }}
  195. />
  196. </>
  197. )
  198. ) : tab === 'lending' ? (
  199. !lending || !lending.success || !lending.data ? (
  200. <div className='stock-detail__error' role='alert'>
  201. <strong>대차 시계열을 불러오지 못했습니다.</strong>
  202. 잠시 후 다시 시도해 주세요.
  203. </div>
  204. ) : (
  205. <>
  206. <StockLending rows={lending.data.list} />
  207. <Pager
  208. total={lending.data.total}
  209. page={page}
  210. perPage={LENDING_PER_PAGE}
  211. basePath={`/stock/${detail.code}`}
  212. query={{ tab: 'lending' }}
  213. />
  214. </>
  215. )
  216. ) : tab === 'cb' ? (
  217. !cbExercises || !cbExercises.success || !cbExercises.data ? (
  218. <div className='stock-detail__error' role='alert'>
  219. <strong>전환권 행사 내역을 불러오지 못했습니다.</strong>
  220. 잠시 후 다시 시도해 주세요.
  221. </div>
  222. ) : (
  223. <>
  224. <StockCbExercises rows={cbExercises.data.list} />
  225. <Pager
  226. total={cbExercises.data.total}
  227. page={page}
  228. perPage={CB_PER_PAGE}
  229. basePath={`/stock/${detail.code}`}
  230. query={{ tab: 'cb' }}
  231. />
  232. </>
  233. )
  234. ) : tab !== 'price' ? (
  235. <p className='stock-detail__placeholder'>준비 중입니다.</p>
  236. ) : !history || !history.success || !history.data ? (
  237. <div className='stock-detail__error' role='alert'>
  238. <strong>일별 시세를 불러오지 못했습니다.</strong>
  239. 잠시 후 다시 시도해 주세요.
  240. </div>
  241. ) : history.data.list.length === 0 ? (
  242. <p className='stock-detail__empty'>수집된 일별 시세가 없습니다.</p>
  243. ) : (
  244. <>
  245. <table className='stock-detail__table'>
  246. <caption>{detail.name} 일별 시세 — 날짜, 종가, 등락, 시가, 고가, 저가, 거래량, 거래대금</caption>
  247. <thead>
  248. <tr>
  249. <th scope='col'>날짜</th>
  250. <th scope='col'>종가</th>
  251. <th scope='col'>등락률</th>
  252. <th scope='col' className='stock-detail__col--sub'>시가</th>
  253. <th scope='col' className='stock-detail__col--sub'>고가</th>
  254. <th scope='col' className='stock-detail__col--sub'>저가</th>
  255. <th scope='col'>거래량</th>
  256. <th scope='col' className='stock-detail__col--sub'>거래대금</th>
  257. </tr>
  258. </thead>
  259. <tbody>
  260. {history.data.list.map(row => {
  261. const rowDirection = changeDirection(row.changeRate);
  262. return (
  263. <tr key={row.tradingDate}>
  264. <td>{row.tradingDate}</td>
  265. <td>{formatNumber(row.close)}</td>
  266. <td className={`stock-change stock-change--${rowDirection}`}>
  267. {changeSymbol(rowDirection)} {formatChangeRate(row.changeRate)}
  268. </td>
  269. <td className='stock-detail__col--sub'>{formatNumber(row.open)}</td>
  270. <td className='stock-detail__col--sub'>{formatNumber(row.high)}</td>
  271. <td className='stock-detail__col--sub'>{formatNumber(row.low)}</td>
  272. <td>{formatNumber(row.volume)}</td>
  273. <td className='stock-detail__col--sub'>{formatKrwCompact(row.tradingValue)}</td>
  274. </tr>
  275. );
  276. })}
  277. </tbody>
  278. </table>
  279. <Pager
  280. total={history.data.total}
  281. page={page}
  282. perPage={HISTORY_PER_PAGE}
  283. basePath={`/stock/${detail.code}`}
  284. query={{}}
  285. />
  286. </>
  287. )}
  288. </section>
  289. </div>
  290. );
  291. }